loki-mode 9.22.12 → 9.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/lib/claude-flags.sh +69 -5
  4. package/autonomy/lib/contract-scaffold/scaffold.sh +1 -1
  5. package/autonomy/loki +324 -74
  6. package/autonomy/run.sh +176 -40
  7. package/autonomy/verify.sh +16 -3
  8. package/bin/loki +25 -1
  9. package/dashboard/__init__.py +1 -1
  10. package/dashboard/control.py +88 -4
  11. package/dashboard/server.py +50 -48
  12. package/docs/INSTALLATION.md +2 -2
  13. package/docs/RARV-C-100X-PLAN.md +9 -1
  14. package/docs/enterprise/performance.md +51 -9
  15. package/docs/exit-codes.md +4 -0
  16. package/loki-ts/dist/cockpit.js +1 -1
  17. package/loki-ts/dist/loki.js +634 -549
  18. package/loki-ts/package.json +2 -2
  19. package/mcp/__init__.py +1 -1
  20. package/package.json +6 -5
  21. package/plugins/loki-mode/.claude-plugin/plugin.json +2 -3
  22. package/providers/loader.sh +2 -2
  23. package/providers/model_catalog.json +26 -8
  24. package/providers/models.sh +1 -1
  25. package/providers/opencode.sh +1 -1
  26. package/skills/providers.md +42 -14
  27. package/tools/normalize-package-permissions.mjs +34 -0
  28. package/web-app/dist/assets/{AdminPage-DLhWX6si.js → AdminPage-PyyVXg4b.js} +1 -1
  29. package/web-app/dist/assets/{Avatar-CqyDcUxl.js → Avatar-CTUlr_Xf.js} +1 -1
  30. package/web-app/dist/assets/{Badge-BKGSt1n2.js → Badge-BQeXvmg8.js} +1 -1
  31. package/web-app/dist/assets/{Button-DuuKHXre.js → Button-Dc6-P6pz.js} +1 -1
  32. package/web-app/dist/assets/{CockpitPage-DdClYERC.js → CockpitPage-BspOJ3Fv.js} +1 -1
  33. package/web-app/dist/assets/{ComparePage-CUWlqBxv.js → ComparePage-CFXIM--Z.js} +1 -1
  34. package/web-app/dist/assets/{ErrorBoundary-RwfGKyRJ.js → ErrorBoundary-C7vQYDW9.js} +1 -1
  35. package/web-app/dist/assets/{EvidenceReceiptPanel-CzIgk9sg.js → EvidenceReceiptPanel-CqjsjTOs.js} +1 -1
  36. package/web-app/dist/assets/{GitHubIssuesPanel-CsXVKuyJ.js → GitHubIssuesPanel-DyuNDMgD.js} +1 -1
  37. package/web-app/dist/assets/{GitHubPRsPanel-CYRp1eKS.js → GitHubPRsPanel-BtAq20xF.js} +1 -1
  38. package/web-app/dist/assets/{HomePage-CVJcI5pQ.js → HomePage-BuVFOrO8.js} +1 -1
  39. package/web-app/dist/assets/{LoginPage-BBJNV9Qb.js → LoginPage-CuhhrxGb.js} +1 -1
  40. package/web-app/dist/assets/{MagicPage-CIi9P8jK.js → MagicPage-D2KZPX3F.js} +1 -1
  41. package/web-app/dist/assets/{MetricsPage-Bm34ipiy.js → MetricsPage-DFEYJl9z.js} +1 -1
  42. package/web-app/dist/assets/{NotFoundPage-DpTbcC2P.js → NotFoundPage-CTnnaWlL.js} +1 -1
  43. package/web-app/dist/assets/{ProjectPage-CY2SMGx9.js → ProjectPage-uJFaoM8-.js} +55 -55
  44. package/web-app/dist/assets/{ProjectsPage-DSrXUUKJ.js → ProjectsPage-8lKAOBIW.js} +1 -1
  45. package/web-app/dist/assets/{SettingsPage-jvDyOqYi.js → SettingsPage-D1BBd5qy.js} +1 -1
  46. package/web-app/dist/assets/{ShowcasePage-C8tFWKau.js → ShowcasePage-CmQ0n8iO.js} +1 -1
  47. package/web-app/dist/assets/{SystemSettingsPage-DH4FTH3K.js → SystemSettingsPage-CSHaVyuD.js} +1 -1
  48. package/web-app/dist/assets/{TeamsPage-C1fQYauF.js → TeamsPage-BS0TtVUa.js} +1 -1
  49. package/web-app/dist/assets/{TemplatesPage-CSTFt-4P.js → TemplatesPage-C2zPnnf2.js} +1 -1
  50. package/web-app/dist/assets/{TerminalOutput-Cu4NsyN6.js → TerminalOutput-Dle1klqq.js} +1 -1
  51. package/web-app/dist/assets/{activity-CtjsDzmh.js → activity-DQp5U9f9.js} +1 -1
  52. package/web-app/dist/assets/{bell-C5es1xAs.js → bell-BqkTmgL7.js} +1 -1
  53. package/web-app/dist/assets/{bot-DE04U5Lc.js → bot-DILhaIGH.js} +1 -1
  54. package/web-app/dist/assets/{check-DSKKA9EU.js → check-BC9x7oPm.js} +1 -1
  55. package/web-app/dist/assets/{chevron-left-DJ13W5AR.js → chevron-left--yqvAfjx.js} +1 -1
  56. package/web-app/dist/assets/{circle-alert-CS0BfqAd.js → circle-alert-DOH6PcgZ.js} +1 -1
  57. package/web-app/dist/assets/{clock-D1Y5nruk.js → clock-DKSDFsE6.js} +1 -1
  58. package/web-app/dist/assets/{cloud-nU6LBoZL.js → cloud-DRBU3JYs.js} +1 -1
  59. package/web-app/dist/assets/{code-xml-BZfvIdFo.js → code-xml-D2LqszR9.js} +1 -1
  60. package/web-app/dist/assets/{copy-CK_2O1Xv.js → copy-BKW4U4Qr.js} +1 -1
  61. package/web-app/dist/assets/{database-DJiwznAm.js → database-D7ceedlh.js} +1 -1
  62. package/web-app/dist/assets/{dollar-sign-pO20A3QY.js → dollar-sign-D0A47egY.js} +1 -1
  63. package/web-app/dist/assets/{file-code-corner-Besy6vek.js → file-code-corner-CH_Qbtjo.js} +1 -1
  64. package/web-app/dist/assets/{file-plus-DeaswxYY.js → file-plus-CUNMjHk9.js} +1 -1
  65. package/web-app/dist/assets/{folder-open-cr1UvQLI.js → folder-open-CQ_SVq94.js} +1 -1
  66. package/web-app/dist/assets/{git-commit-horizontal-BLxnfbSn.js → git-commit-horizontal-DQUBe0oD.js} +1 -1
  67. package/web-app/dist/assets/{globe-DrhG1NfX.js → globe-gZnaGg8N.js} +1 -1
  68. package/web-app/dist/assets/{hammer-CeZtPBfE.js → hammer-BhJC5DMK.js} +1 -1
  69. package/web-app/dist/assets/{index-CLvjO22Z.js → index-BMmYYws0.js} +2 -2
  70. package/web-app/dist/assets/{layers-fldADzyE.js → layers-glXeoUbJ.js} +1 -1
  71. package/web-app/dist/assets/{lightbulb-CznOfsPb.js → lightbulb-gXn8H0Lf.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle---lPB1Fs.js → loader-circle-Do2ke8xm.js} +1 -1
  73. package/web-app/dist/assets/{lock-D5jVF-tO.js → lock-Bgplb1_n.js} +1 -1
  74. package/web-app/dist/assets/{mail-D7HkLnlW.js → mail-j4Lo8hfB.js} +1 -1
  75. package/web-app/dist/assets/{minus-C-dT19Ct.js → minus-dhrzKSK5.js} +1 -1
  76. package/web-app/dist/assets/{package-Cm9B-cKE.js → package-CInWQQ97.js} +1 -1
  77. package/web-app/dist/assets/{plus-CQwOffLD.js → plus-BpYvoLlZ.js} +1 -1
  78. package/web-app/dist/assets/{refresh-cw-CvWJZLbN.js → refresh-cw-F6koqEgF.js} +1 -1
  79. package/web-app/dist/assets/{rotate-ccw-C2Q1LYgM.js → rotate-ccw-CQ2W5S8f.js} +1 -1
  80. package/web-app/dist/assets/{save-BP0MvpoQ.js → save-DMCYGm06.js} +1 -1
  81. package/web-app/dist/assets/{server-4i4VZXix.js → server-Bj8EX4Z1.js} +1 -1
  82. package/web-app/dist/assets/{shield-alert-D2_bgb7j.js → shield-alert-CiYuulCU.js} +1 -1
  83. package/web-app/dist/assets/{thumbs-up-vACbzeXj.js → thumbs-up-BqEh1puP.js} +1 -1
  84. package/web-app/dist/assets/{trash-2-EYjdESLs.js → trash-2-CCwouZJJ.js} +1 -1
  85. package/web-app/dist/assets/{trending-down-BWBdcvE3.js → trending-down-Cx1vhE4o.js} +1 -1
  86. package/web-app/dist/assets/{trending-up-Bix6V_-Y.js → trending-up-BUhVYJ2T.js} +1 -1
  87. package/web-app/dist/assets/{upload-B7hh6ERF.js → upload-D5IsZeFM.js} +1 -1
  88. package/web-app/dist/assets/{usePolling-b_e-k-IN.js → usePolling-CJbfhFcH.js} +1 -1
  89. package/web-app/dist/assets/{user-pmjZKWEg.js → user-BRLhezsD.js} +1 -1
  90. package/web-app/dist/index.html +1 -1
  91. package/web-app/requirements.txt +1 -0
  92. package/web-app/server.py +160 -31
package/autonomy/loki CHANGED
@@ -1456,7 +1456,7 @@ show_help() {
1456
1456
  echo "list with 'loki help aliases'."
1457
1457
  echo ""
1458
1458
  echo "Options for 'start':"
1459
- echo " --provider NAME AI provider: claude (default), codex, cline, aider"
1459
+ echo " --provider NAME AI provider: claude (default), codex, cline, aider, opencode"
1460
1460
  echo " --parallel Enable parallel mode with git worktrees"
1461
1461
  echo " --bg, --background Run in background mode"
1462
1462
  echo " --simple Force simple complexity tier (3 phases)"
@@ -1502,7 +1502,7 @@ show_help() {
1502
1502
  echo " loki cleanup # Kill orphaned processes"
1503
1503
  echo ""
1504
1504
  echo " # Providers + model routing"
1505
- echo " loki provider list # Show 4 providers (claude/codex/cline/aider)"
1505
+ echo " loki provider list # Show available providers, including opencode"
1506
1506
  echo " loki provider set codex # Switch active provider"
1507
1507
  echo " # Cheap / open models (DeepSeek, GLM, MiniMax, Kimi) via OpenRouter:"
1508
1508
  echo " loki provider set aider # aider + cline reach any OpenRouter model"
@@ -2067,7 +2067,7 @@ cmd_start() {
2067
2067
  echo " building locally; polls until the job finishes."
2068
2068
  echo " Token from LOKI_REMOTE_TOKEN / LOKI_REMOTE_TOKEN_FILE"
2069
2069
  echo " (never an argument). Env: LOKI_REMOTE_URL."
2070
- echo " --provider NAME AI provider: claude (default), codex, cline, aider"
2070
+ echo " --provider NAME AI provider: claude (default), codex, cline, aider, opencode"
2071
2071
  echo " --parallel Enable parallel mode with git worktrees"
2072
2072
  echo " --allow-haiku Enable Haiku model for the fast tier (default: disabled)"
2073
2073
  echo " --bg, --background Run in background mode"
@@ -2161,7 +2161,7 @@ cmd_start() {
2161
2161
  args+=("--provider" "$provider")
2162
2162
  shift 2
2163
2163
  else
2164
- echo -e "${RED}--provider requires a value (claude, codex, cline, aider)${NC}"
2164
+ echo -e "${RED}--provider requires a value (claude, codex, cline, aider, opencode)${NC}"
2165
2165
  exit 1
2166
2166
  fi
2167
2167
  ;;
@@ -3367,7 +3367,7 @@ cmd_start() {
3367
3367
  # can drop the marker when the originating process is no longer alive
3368
3368
  # (SIGKILL / crash paths that skip our cleanup hooks).
3369
3369
  case "$provider" in
3370
- claude|codex|cline|aider)
3370
+ claude|codex|cline|aider|opencode)
3371
3371
  mkdir -p "$LOKI_DIR/state" 2>/dev/null || true
3372
3372
  printf '%s:%s:%s\n' "$provider" "$(date +%s)" "$$" \
3373
3373
  > "$LOKI_DIR/state/.cli-provider.tmp" 2>/dev/null \
@@ -3409,6 +3409,7 @@ cmd_start() {
3409
3409
  codex) echo " npm install -g @openai/codex" ;;
3410
3410
  cline) echo " npm install -g cline" ;;
3411
3411
  aider) echo " pip install aider-chat" ;;
3412
+ opencode) echo " npm install -g opencode-ai" ;;
3412
3413
  *) echo " Check the provider documentation for installation." ;;
3413
3414
  esac
3414
3415
  echo ""
@@ -4064,6 +4065,172 @@ PYDASH
4064
4065
  fi
4065
4066
  }
4066
4067
 
4068
+ # Classify one current-project PID registry entry without signalling anything.
4069
+ #
4070
+ # A numeric PID alone is not process identity because the OS reuses PIDs. The registry's
4071
+ # UTC `started` field is the available launch token: the live process must have started
4072
+ # within a bounded window AROUND the recorded registration, and that registration must
4073
+ # itself be corroborated by the entry's st_ctime, which unprivileged userspace cannot
4074
+ # backdate. The file and its parent must also remain current-UID-owned, non-linked,
4075
+ # canonical local state. Any missing validation fails closed.
4076
+ #
4077
+ # Prints exactly one of: OWNED, STALE, REFUSED, UNAVAILABLE.
4078
+ _cleanup_registry_entry_state() {
4079
+ local entry_file="$1" expected_pid="$2"
4080
+ command -v python3 >/dev/null 2>&1 || {
4081
+ printf '%s\n' "UNAVAILABLE"
4082
+ return 0
4083
+ }
4084
+ python3 - "$entry_file" "$expected_pid" <<'PY' 2>/dev/null || printf '%s\n' "REFUSED"
4085
+ import calendar
4086
+ import json
4087
+ import os
4088
+ from pathlib import Path
4089
+ import stat
4090
+ import subprocess
4091
+ import sys
4092
+ import time
4093
+
4094
+
4095
+ def verdict(value: str) -> None:
4096
+ print(value)
4097
+ raise SystemExit(0)
4098
+
4099
+
4100
+ try:
4101
+ entry = Path(sys.argv[1])
4102
+ expected_pid = int(sys.argv[2])
4103
+ parent = entry.parent
4104
+ parent_info = os.lstat(parent)
4105
+ entry_info = os.lstat(entry)
4106
+ if (
4107
+ expected_pid <= 1
4108
+ or entry.name != f"{expected_pid}.json"
4109
+ or not stat.S_ISDIR(parent_info.st_mode)
4110
+ or stat.S_ISLNK(parent_info.st_mode)
4111
+ or parent_info.st_uid != os.getuid()
4112
+ or stat.S_IMODE(parent_info.st_mode) & 0o022
4113
+ or os.path.realpath(parent) != os.path.abspath(parent)
4114
+ or not stat.S_ISREG(entry_info.st_mode)
4115
+ or stat.S_ISLNK(entry_info.st_mode)
4116
+ or entry_info.st_uid != os.getuid()
4117
+ or entry_info.st_nlink != 1
4118
+ or stat.S_IMODE(entry_info.st_mode) & 0o022
4119
+ ):
4120
+ verdict("REFUSED")
4121
+
4122
+ record = json.loads(entry.read_text(encoding="utf-8"))
4123
+ pid = record.get("pid")
4124
+ ppid = record.get("ppid")
4125
+ label = record.get("label")
4126
+ started = record.get("started")
4127
+ if (
4128
+ isinstance(pid, bool)
4129
+ or not isinstance(pid, int)
4130
+ or pid != expected_pid
4131
+ or isinstance(ppid, bool)
4132
+ or not isinstance(ppid, int)
4133
+ or ppid <= 0
4134
+ or not isinstance(label, str)
4135
+ or not label
4136
+ or not isinstance(started, str)
4137
+ ):
4138
+ verdict("REFUSED")
4139
+
4140
+ try:
4141
+ os.kill(pid, 0)
4142
+ except ProcessLookupError:
4143
+ verdict("STALE")
4144
+ except (PermissionError, OSError):
4145
+ verdict("REFUSED")
4146
+
4147
+ # register_pid() (autonomy/run.sh) writes `started` on the line right after the
4148
+ # `&` that spawned the pid, so for a genuine child entry the process launch and
4149
+ # the recorded registration are seconds apart in BOTH directions.
4150
+ #
4151
+ # `kind":"wrapper"` entries are NOT this shape: register_self_wrapper() records
4152
+ # the wrapper's OWN pid deep in main() startup, so the honest gap is unbounded
4153
+ # and no tight window fits. They are also not cmd_cleanup's to reap -- run.sh's
4154
+ # cleanup_orphan_pids owns them behind a parent-dead AND idle AND no-live-child
4155
+ # predicate, because cmd_cleanup's parent-death-only rule is wrong for a
4156
+ # wrapper. Refuse them here rather than widen the window for every entry: a
4157
+ # window wide enough for a wrapper is wide enough to forge a child.
4158
+ if record.get("kind") is not None:
4159
+ verdict("REFUSED")
4160
+
4161
+ SLACK = 10
4162
+ registered_at = calendar.timegm(time.strptime(started, "%Y-%m-%dT%H:%M:%SZ"))
4163
+ if registered_at > time.time() + 5:
4164
+ verdict("REFUSED")
4165
+
4166
+ # `started` is attacker-writable, so it cannot vouch for itself. st_ctime is
4167
+ # the one field in this registry that unprivileged userspace cannot backdate
4168
+ # (utimes moves atime/mtime only), so it pins WHEN the entry was really made.
4169
+ # Without this an attacker just backdates `started` to an old victim's real
4170
+ # launch time and satisfies the window below.
4171
+ if abs(entry_info.st_ctime - registered_at) > SLACK:
4172
+ verdict("REFUSED")
4173
+
4174
+ observed = subprocess.run(
4175
+ ["ps", "-o", "uid=", "-o", "ppid=", "-o", "lstart=", "-p", str(pid)],
4176
+ check=False,
4177
+ capture_output=True,
4178
+ text=True,
4179
+ timeout=2,
4180
+ )
4181
+ # lstart contains spaces, so split off uid and ppid only; the remainder is lstart.
4182
+ fields = observed.stdout.strip().split(maxsplit=2)
4183
+ if observed.returncode != 0 or len(fields) != 3 or int(fields[0]) != os.getuid():
4184
+ verdict("REFUSED")
4185
+
4186
+ # The recorded `ppid` is not decoration: register_pid() (autonomy/run.sh) writes
4187
+ # `"ppid":$$`, so for a genuine entry the registering shell IS the live process's
4188
+ # real parent. Nothing above binds the entry to that parentage, which is the whole
4189
+ # forgery: a fresh, same-uid, correctly-ctime'd entry naming an UNRELATED live pid
4190
+ # with an arbitrary dead `ppid` satisfies every other check and gets TERM then KILL.
4191
+ #
4192
+ # Two honest shapes, and only two:
4193
+ # recorded parent alive -> it must still BE the live parent.
4194
+ # recorded parent dead -> the child was reparented to init (pid 1), which is the
4195
+ # genuine orphan case cmd_cleanup exists to reap.
4196
+ # A forged entry aimed at an unrelated process fails both: that process's real
4197
+ # parent is the attacker's own live shell, which is neither the recorded ppid nor 1.
4198
+ # Both ceilings below fail CLOSED (a genuine entry is skipped and left for
4199
+ # inspection, never an unrelated process signalled), so neither can turn into
4200
+ # the forgery this guard exists to stop:
4201
+ # - if the recorded ppid died and the OS reused that number for an unrelated
4202
+ # live process, this reads "parent alive" and refuses a real orphan;
4203
+ # - on Linux under a PR_SET_CHILD_SUBREAPER ancestor (systemd user scope) an
4204
+ # orphan reparents to the subreaper rather than to 1 and is refused.
4205
+ # Widening either one means accepting an arbitrary live pid as the parent, which
4206
+ # is exactly the hole above, so the strict form stays until a portable way to
4207
+ # identify the real reaper exists. Reparent-to-init matches what the rest of the
4208
+ # runtime already assumes (see run.sh's detached getppid()==1 note).
4209
+ observed_ppid = int(fields[1])
4210
+ recorded_parent_alive = True
4211
+ try:
4212
+ os.kill(ppid, 0)
4213
+ except ProcessLookupError:
4214
+ recorded_parent_alive = False
4215
+ except (PermissionError, OSError):
4216
+ verdict("REFUSED")
4217
+ if observed_ppid != (ppid if recorded_parent_alive else 1):
4218
+ verdict("REFUSED")
4219
+
4220
+ process_started_at = int(time.mktime(time.strptime(fields[2], "%a %b %d %H:%M:%S %Y")))
4221
+ # Upper bound rejects a pid reused AFTER registration. Lower bound rejects a
4222
+ # forged entry aimed at an unrelated process that was already running when
4223
+ # the entry was written. Both halves are required: with only the upper bound
4224
+ # any live same-uid pid older than `started` is signalable.
4225
+ if not (registered_at - SLACK <= process_started_at <= registered_at + 1):
4226
+ verdict("REFUSED")
4227
+ except (OSError, ValueError, TypeError, json.JSONDecodeError, subprocess.SubprocessError):
4228
+ verdict("REFUSED")
4229
+
4230
+ verdict("OWNED")
4231
+ PY
4232
+ }
4233
+
4067
4234
  # Kill orphaned processes from crashed sessions
4068
4235
  cmd_cleanup() {
4069
4236
  # v7.6.2 B-13 fix: --help must print help, not run cleanup as a side effect.
@@ -4086,6 +4253,7 @@ cmd_cleanup() {
4086
4253
  local pids_dir="$LOKI_DIR/pids"
4087
4254
  local killed=0
4088
4255
  local stale=0
4256
+ local refused=0
4089
4257
 
4090
4258
  if [ ! -d "$pids_dir" ]; then
4091
4259
  echo "No PID registry found. Nothing to clean up."
@@ -4102,6 +4270,27 @@ cmd_cleanup() {
4102
4270
  ''|*[!0-9]*) continue ;;
4103
4271
  esac
4104
4272
 
4273
+ local registry_state
4274
+ registry_state=$(_cleanup_registry_entry_state "$entry_file" "$pid")
4275
+ case "$registry_state" in
4276
+ STALE)
4277
+ rm -f "$entry_file"
4278
+ stale=$((stale + 1))
4279
+ continue
4280
+ ;;
4281
+ OWNED) ;;
4282
+ UNAVAILABLE)
4283
+ echo -e " ${YELLOW}Refusing${NC} PID=$pid (python3 unavailable: ownership cannot be verified)"
4284
+ refused=$((refused + 1))
4285
+ continue
4286
+ ;;
4287
+ *)
4288
+ echo -e " ${YELLOW}Refusing${NC} PID=$pid (registry ownership or launch identity is not valid)"
4289
+ refused=$((refused + 1))
4290
+ continue
4291
+ ;;
4292
+ esac
4293
+
4105
4294
  local label=""
4106
4295
  local ppid_val=""
4107
4296
  # Parse JSON fields (python3 with shell fallback)
@@ -4113,50 +4302,61 @@ cmd_cleanup() {
4113
4302
  ppid_val=$(sed 's/.*"ppid"://' "$entry_file" 2>/dev/null | sed 's/[,}].*//' | head -1) || true
4114
4303
  fi
4115
4304
 
4116
- if kill -0 "$pid" 2>/dev/null; then
4117
- # Process is alive - check if parent is dead (orphan)
4118
- local is_orphan=false
4119
- # Validate ppid_val is numeric before using with kill
4120
- case "$ppid_val" in ''|*[!0-9]*) ppid_val="" ;; esac
4121
- if [ -n "$ppid_val" ] && ! kill -0 "$ppid_val" 2>/dev/null; then
4122
- is_orphan=true
4123
- fi
4124
-
4125
- if [ "$is_orphan" = true ] || [ "${1:-}" = "--force" ]; then
4126
- echo -e " ${RED}Killing${NC} PID=$pid label=$label (parent $ppid_val dead)"
4127
- kill "$pid" 2>/dev/null || true
4128
- sleep 0.5
4129
- if kill -0 "$pid" 2>/dev/null; then
4130
- kill -9 "$pid" 2>/dev/null || true
4131
- fi
4132
- rm -f "$entry_file"
4133
- killed=$((killed + 1))
4134
- else
4135
- echo -e " ${GREEN}Alive${NC} PID=$pid label=$label (parent $ppid_val alive)"
4305
+ # The ownership gate above proved this exact live process matches the
4306
+ # current-project registry launch token. Check only whether its exact
4307
+ # recorded parent is gone before normal orphan cleanup.
4308
+ local is_orphan=false
4309
+ case "$ppid_val" in ''|*[!0-9]*) ppid_val="" ;; esac
4310
+ if [ -n "$ppid_val" ] && ! kill -0 "$ppid_val" 2>/dev/null; then
4311
+ is_orphan=true
4312
+ fi
4313
+
4314
+ if [ "$is_orphan" = true ] || [ "${1:-}" = "--force" ]; then
4315
+ # Close the validation-to-signal window as far as this portable PID
4316
+ # registry permits. Re-check again after TERM before any KILL so a
4317
+ # rapidly reused PID is never escalated under the old identity.
4318
+ registry_state=$(_cleanup_registry_entry_state "$entry_file" "$pid")
4319
+ if [ "$registry_state" != "OWNED" ]; then
4320
+ echo -e " ${YELLOW}Refusing${NC} PID=$pid (launch identity changed before signalling)"
4321
+ refused=$((refused + 1))
4322
+ continue
4323
+ fi
4324
+ echo -e " ${RED}Killing${NC} PID=$pid label=$label (parent $ppid_val dead)"
4325
+ if ! kill "$pid" 2>/dev/null; then
4326
+ echo -e " ${YELLOW}Refusing${NC} PID=$pid (TERM could not be delivered)"
4327
+ refused=$((refused + 1))
4328
+ continue
4136
4329
  fi
4330
+ sleep 0.5
4331
+ registry_state=$(_cleanup_registry_entry_state "$entry_file" "$pid")
4332
+ case "$registry_state" in
4333
+ STALE)
4334
+ rm -f "$entry_file"
4335
+ killed=$((killed + 1))
4336
+ ;;
4337
+ OWNED)
4338
+ if kill -9 "$pid" 2>/dev/null; then
4339
+ rm -f "$entry_file"
4340
+ killed=$((killed + 1))
4341
+ else
4342
+ echo -e " ${YELLOW}Refusing${NC} PID=$pid (KILL could not be delivered)"
4343
+ refused=$((refused + 1))
4344
+ fi
4345
+ ;;
4346
+ *)
4347
+ echo -e " ${YELLOW}Refusing${NC} PID=$pid (launch identity changed before escalation)"
4348
+ refused=$((refused + 1))
4349
+ ;;
4350
+ esac
4137
4351
  else
4138
- # Process is dead - clean up stale entry
4139
- rm -f "$entry_file"
4140
- stale=$((stale + 1))
4352
+ echo -e " ${GREEN}Alive${NC} PID=$pid label=$label (parent $ppid_val alive)"
4141
4353
  fi
4142
4354
  done
4143
4355
 
4144
4356
  echo ""
4145
- echo "Results: $killed orphan(s) killed, $stale stale entries cleaned"
4146
-
4147
- # Also kill orphaned loki-run temp scripts
4148
- local temp_killed=0
4149
- if pgrep -f "loki-run-" >/dev/null 2>&1; then
4150
- if ! is_session_running; then
4151
- echo "Killing orphaned loki-run temp scripts..."
4152
- pkill -f "loki-run-" 2>/dev/null || true
4153
- sleep 0.5
4154
- pkill -9 -f "loki-run-" 2>/dev/null || true
4155
- temp_killed=1
4156
- fi
4157
- fi
4357
+ echo "Results: $killed orphan(s) killed, $stale stale entries cleaned, $refused entry(s) refused"
4158
4358
 
4159
- if [ $killed -eq 0 ] && [ $stale -eq 0 ] && [ $temp_killed -eq 0 ]; then
4359
+ if [ $killed -eq 0 ] && [ $stale -eq 0 ] && [ $refused -eq 0 ]; then
4160
4360
  echo -e "${GREEN}System is clean. No orphans found.${NC}"
4161
4361
  fi
4162
4362
  }
@@ -4840,7 +5040,14 @@ cmd_ship() {
4840
5040
  echo -e "${BOLD}Running quality gates on the diff (loki review)...${NC}"
4841
5041
  echo ""
4842
5042
  local rc=0
4843
- cmd_review "${review_args[@]}" || rc=$?
5043
+ # Bash 3.2 treats an empty-array expansion as an unbound variable under
5044
+ # `set -u`. macOS still ships that shell, so the documented zero-option
5045
+ # `loki ship` path must call review without expanding an empty array.
5046
+ if [ "${#review_args[@]}" -gt 0 ]; then
5047
+ cmd_review "${review_args[@]}" || rc=$?
5048
+ else
5049
+ cmd_review || rc=$?
5050
+ fi
4844
5051
 
4845
5052
  if [ "$rc" -ge 1 ]; then
4846
5053
  echo ""
@@ -5476,9 +5683,12 @@ cmd_status() {
5476
5683
  cline)
5477
5684
  capability="near-full mode"
5478
5685
  ;;
5686
+ opencode)
5687
+ capability="model-agnostic mode"
5688
+ ;;
5479
5689
  esac
5480
5690
  echo -e "${CYAN}Provider:${NC} $current_provider ($capability)"
5481
- echo -e "${DIM} Switch with: loki provider set <claude|codex|cline|aider>${NC}"
5691
+ echo -e "${DIM} Switch with: loki provider set <claude|codex|cline|aider|opencode>${NC}"
5482
5692
  echo ""
5483
5693
 
5484
5694
  # Show running sessions (v6.4.0 - concurrent session support)
@@ -5679,7 +5889,7 @@ def _read_cli_provider(loki_dir):
5679
5889
  return None, False
5680
5890
  prov = parts[0]
5681
5891
  ts = int(parts[1])
5682
- if prov not in ('claude', 'codex', 'cline', 'aider'):
5892
+ if prov not in ('claude', 'codex', 'cline', 'aider', 'opencode'):
5683
5893
  return None, False
5684
5894
  if time.time() - ts > 86400:
5685
5895
  return None, False
@@ -6338,6 +6548,9 @@ cmd_provider_show() {
6338
6548
  codex|aider)
6339
6549
  echo -e "${YELLOW}Status:${NC} Degraded mode (sequential only)"
6340
6550
  ;;
6551
+ opencode)
6552
+ echo -e "${GREEN}Status:${NC} Model-agnostic mode (75+ providers, MCP)"
6553
+ ;;
6341
6554
  esac
6342
6555
 
6343
6556
  if [ -n "$saved_provider" ]; then
@@ -6356,7 +6569,7 @@ cmd_provider_set() {
6356
6569
 
6357
6570
  if [ -z "$new_provider" ]; then
6358
6571
  echo -e "${RED}Error: Provider name required${NC}"
6359
- echo "Usage: loki provider set <claude|codex|cline|aider>"
6572
+ echo "Usage: loki provider set <claude|codex|cline|aider|opencode>"
6360
6573
  exit 2
6361
6574
  fi
6362
6575
 
@@ -6364,14 +6577,14 @@ cmd_provider_set() {
6364
6577
  case "$new_provider" in
6365
6578
  gemini)
6366
6579
  echo -e "${RED}Error: Provider 'gemini' is deprecated as of v7.5.18 and has been removed.${NC}"
6367
- echo "Active providers: claude, codex, cline, aider"
6580
+ echo "Active providers: claude, codex, cline, aider, opencode"
6368
6581
  exit 1
6369
6582
  ;;
6370
- claude|codex|cline|aider)
6583
+ claude|codex|cline|aider|opencode)
6371
6584
  ;;
6372
6585
  *)
6373
6586
  echo -e "${RED}Error: Invalid provider '$new_provider'${NC}"
6374
- echo "Valid providers: claude, codex, cline, aider"
6587
+ echo "Valid providers: claude, codex, cline, aider, opencode"
6375
6588
  exit 1
6376
6589
  ;;
6377
6590
  esac
@@ -6393,6 +6606,9 @@ cmd_provider_set() {
6393
6606
  aider)
6394
6607
  echo "Install: pip install aider-chat"
6395
6608
  ;;
6609
+ opencode)
6610
+ echo "Install: npm install -g opencode-ai"
6611
+ ;;
6396
6612
  esac
6397
6613
  echo ""
6398
6614
  echo -e "${DIM}Setting provider anyway...${NC}"
@@ -6531,6 +6747,20 @@ cmd_provider_info() {
6531
6747
  echo ""
6532
6748
  echo "Status: Degraded mode (Tier 3)"
6533
6749
  ;;
6750
+ opencode)
6751
+ echo "Name: opencode (75+ Providers)"
6752
+ echo "Vendor: opencode (model-agnostic registry)"
6753
+ echo "CLI: opencode"
6754
+ echo "Flag: run --auto"
6755
+ echo ""
6756
+ echo "Features:"
6757
+ echo " - Autonomous mode"
6758
+ echo " - 75+ model providers and custom endpoints"
6759
+ echo " - Local model support"
6760
+ echo " - MCP server support"
6761
+ echo ""
6762
+ echo "Status: Model-agnostic mode"
6763
+ ;;
6534
6764
  *)
6535
6765
  echo -e "${RED}Unknown provider: $provider${NC}"
6536
6766
  exit 1
@@ -9871,7 +10101,7 @@ cmd_run() {
9871
10101
  echo " --dry-run Preview generated PRD without starting"
9872
10102
  echo " --no-start Generate PRD but don't start execution"
9873
10103
  echo " --output FILE Save PRD to custom path"
9874
- echo " --provider NAME AI provider: claude (default), codex, cline, aider"
10104
+ echo " --provider NAME AI provider: claude (default), codex, cline, aider, opencode"
9875
10105
  echo " --parallel Enable parallel mode with git worktrees"
9876
10106
  echo " --bg, --background Run in background mode"
9877
10107
  echo " --simple Force simple complexity tier"
@@ -9946,7 +10176,7 @@ cmd_run() {
9946
10176
  # v7.7.11 (council fix): validate against canonical set; include
9947
10177
  # PID so reader can drop the marker when originating process is gone.
9948
10178
  case "$2" in
9949
- claude|codex|cline|aider)
10179
+ claude|codex|cline|aider|opencode)
9950
10180
  mkdir -p "${LOKI_DIR:-.loki}/state" 2>/dev/null || true
9951
10181
  printf '%s:%s:%s\n' "$2" "$(date +%s)" "$$" \
9952
10182
  > "${LOKI_DIR:-.loki}/state/.cli-provider.tmp" 2>/dev/null \
@@ -9966,7 +10196,7 @@ cmd_run() {
9966
10196
  # UT2-13: Record CLI provider at parse time (atomic write).
9967
10197
  # v7.7.11 (council fix): validate + include PID.
9968
10198
  case "$provider_override" in
9969
- claude|codex|cline|aider)
10199
+ claude|codex|cline|aider|opencode)
9970
10200
  mkdir -p "${LOKI_DIR:-.loki}/state" 2>/dev/null || true
9971
10201
  printf '%s:%s:%s\n' "$provider_override" "$(date +%s)" "$$" \
9972
10202
  > "${LOKI_DIR:-.loki}/state/.cli-provider.tmp" 2>/dev/null \
@@ -10597,7 +10827,7 @@ cmd_issue() {
10597
10827
  echo " --output FILE Save PRD to custom path (default: .loki/prd-issue-N.md)"
10598
10828
  echo ""
10599
10829
  echo "Options passed to 'start' (when --start is used):"
10600
- echo " --provider NAME AI provider: claude (default), codex, cline, aider"
10830
+ echo " --provider NAME AI provider: claude (default), codex, cline, aider, opencode"
10601
10831
  echo " --parallel Enable parallel mode with git worktrees"
10602
10832
  echo " --bg, --background Run in background mode"
10603
10833
  echo ""
@@ -11994,7 +12224,7 @@ cmd_config() {
11994
12224
  echo " model.planning Model for planning tier"
11995
12225
  echo " model.development Model for development tier"
11996
12226
  echo " model.fast Model for fast tier"
11997
- echo " provider Default AI provider: claude, codex, cline, aider"
12227
+ echo " provider Default AI provider: claude, codex, cline, aider, opencode"
11998
12228
  echo " issue.provider Default issue provider: github, gitlab, jira, azure_devops"
11999
12229
  echo " blind_validation Blind validation mode: true, false (default: true)"
12000
12230
  echo " adversarial_testing Adversarial testing: true, false (default: true)"
@@ -12055,11 +12285,11 @@ cmd_config_set() {
12055
12285
  provider)
12056
12286
  case "$value" in
12057
12287
  gemini)
12058
- echo -e "${RED}Invalid provider: 'gemini' is deprecated as of v7.5.18. Active providers: claude, codex, cline, aider${NC}"
12288
+ echo -e "${RED}Invalid provider: 'gemini' is deprecated as of v7.5.18. Active providers: claude, codex, cline, aider, opencode${NC}"
12059
12289
  return 1
12060
12290
  ;;
12061
- claude|codex|cline|aider) ;;
12062
- *) echo -e "${RED}Invalid provider: $value (expected: claude, codex, cline, aider)${NC}"; return 1 ;;
12291
+ claude|codex|cline|aider|opencode) ;;
12292
+ *) echo -e "${RED}Invalid provider: $value (expected: claude, codex, cline, aider, opencode)${NC}"; return 1 ;;
12063
12293
  esac
12064
12294
  ;;
12065
12295
  issue.provider)
@@ -12476,11 +12706,20 @@ cmd_setup_skill() {
12476
12706
  continue
12477
12707
  fi
12478
12708
 
12479
- # Remove broken symlink
12480
- if [ -L "$target_dir" ]; then
12709
+ # A dangling link is safe to repair. Any live path without SKILL.md is
12710
+ # operator-owned: `ln -sf SOURCE DIRECTORY` would otherwise create a
12711
+ # nested link and report success, while a regular file could be
12712
+ # replaced outright. Refuse both cases without changing the path.
12713
+ if [ -L "$target_dir" ] && [ ! -e "$target_dir" ]; then
12481
12714
  rm -f "$target_dir"
12482
12715
  fi
12483
12716
 
12717
+ if [ -e "$target_dir" ] || [ -L "$target_dir" ]; then
12718
+ echo -e " ${RED}FAIL${NC} $target_name ${DIM}($short_path exists without SKILL.md; move it aside and re-run)${NC}"
12719
+ failed=$((failed + 1))
12720
+ continue
12721
+ fi
12722
+
12484
12723
  # Create parent directory
12485
12724
  if [ ! -d "$parent_dir" ]; then
12486
12725
  mkdir -p "$parent_dir" 2>/dev/null || true
@@ -12870,7 +13109,7 @@ cmd_doctor() {
12870
13109
  echo " required egress remains."
12871
13110
  echo ""
12872
13111
  echo "Checks: node, python3, jq, git, curl, bash version,"
12873
- echo " claude/codex CLIs, disk space, and cockpit render capability."
13112
+ echo " provider CLIs, disk space, and cockpit render capability."
12874
13113
  return 0
12875
13114
  ;;
12876
13115
  *)
@@ -12970,6 +13209,9 @@ cmd_doctor() {
12970
13209
  aider)
12971
13210
  version=$(aider --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
12972
13211
  ;;
13212
+ opencode)
13213
+ version=$(opencode --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
13214
+ ;;
12973
13215
  esac
12974
13216
 
12975
13217
  local version_display=""
@@ -13025,6 +13267,7 @@ cmd_doctor() {
13025
13267
  codex) echo "npm install -g @openai/codex" ;;
13026
13268
  cline) echo "npm install -g cline" ;;
13027
13269
  aider) echo "pip install aider-chat" ;;
13270
+ opencode) echo "npm install -g opencode-ai" ;;
13028
13271
  *) echo "" ;;
13029
13272
  esac
13030
13273
  }
@@ -13046,6 +13289,7 @@ cmd_doctor() {
13046
13289
  doctor_check_provider "Codex CLI" codex || true
13047
13290
  doctor_check_provider "Cline CLI" cline || true
13048
13291
  doctor_check_provider "Aider CLI" aider || true
13292
+ doctor_check_provider "opencode CLI" opencode || true
13049
13293
 
13050
13294
  # Check if at least one provider is installed (detect_any_provider is the
13051
13295
  # shared helper from provider-offer.sh, extracted from this exact loop).
@@ -13681,6 +13925,7 @@ checks.append(check_tool('Claude CLI', 'claude', 'optional'))
13681
13925
  checks.append(check_tool('Codex CLI', 'codex', 'optional'))
13682
13926
  checks.append(check_tool('Cline CLI', 'cline', 'optional'))
13683
13927
  checks.append(check_tool('Aider CLI', 'aider', 'optional'))
13928
+ checks.append(check_tool('opencode CLI', 'opencode', 'optional'))
13684
13929
 
13685
13930
  # Disk space
13686
13931
  disk_gb = None
@@ -13788,22 +14033,20 @@ _SKILL_ENTRIES = (
13788
14033
  skills = []
13789
14034
  for _sk_name, _sk_rel in _SKILL_ENTRIES:
13790
14035
  _sk_dir = os.path.join(os.path.expanduser('~'), _sk_rel)
14036
+ # JSON is commonly archived or forwarded to automation. Keep the useful
14037
+ # user-relative location without exposing the machine-specific home path.
14038
+ _sk_json_path = '~/' + _sk_rel
13791
14039
  if os.path.isfile(os.path.join(_sk_dir, 'SKILL.md')):
13792
- skills.append({'name': _sk_name, 'path': _sk_dir, 'status': 'pass',
14040
+ skills.append({'name': _sk_name, 'path': _sk_json_path, 'status': 'pass',
13793
14041
  'detail': None, 'required': 'required'})
13794
14042
  elif os.path.islink(_sk_dir):
13795
14043
  # islink is true for a DANGLING link (lstat-based), which is exactly
13796
14044
  # the broken case the text path reports as FAIL.
13797
- try:
13798
- _sk_target = os.readlink(_sk_dir)
13799
- except OSError:
13800
- _sk_target = 'unknown'
13801
- skills.append({'name': _sk_name, 'path': _sk_dir, 'status': 'fail',
13802
- 'detail': 'broken symlink -> ' + _sk_target
13803
- + '. Fix: loki setup-skill',
14045
+ skills.append({'name': _sk_name, 'path': _sk_json_path, 'status': 'fail',
14046
+ 'detail': 'broken symlink. Fix: loki setup-skill',
13804
14047
  'required': 'required'})
13805
14048
  else:
13806
- skills.append({'name': _sk_name, 'path': _sk_dir, 'status': 'warn',
14049
+ skills.append({'name': _sk_name, 'path': _sk_json_path, 'status': 'warn',
13807
14050
  'detail': 'not found - run loki setup-skill',
13808
14051
  'required': 'required'})
13809
14052
 
@@ -13824,7 +14067,7 @@ elif disk_status == 'fail': fail_count += 1
13824
14067
  elif disk_status == 'warn': warn_count += 1
13825
14068
 
13826
14069
  # AGGREGATE PROVIDER CHECK. Each provider CLI is individually optional --
13827
- # you need Claude OR Codex OR Cline OR Aider, not all four -- so no single
14070
+ # you need one of Claude, Codex, Cline, Aider, or opencode -- so no single
13828
14071
  # one can be marked required. But having NONE is a blocker, and the text
13829
14072
  # path reports it as one (the No-AI-provider branch near autonomy/loki:11000).
13830
14073
  #
@@ -13915,6 +14158,7 @@ result = {
13915
14158
  }
13916
14159
 
13917
14160
  print(json.dumps(result, indent=2))
14161
+ sys.exit(0 if result['summary']['ok'] else 1)
13918
14162
  "
13919
14163
  }
13920
14164
 
@@ -17268,8 +17512,11 @@ except Exception: pass
17268
17512
  # shellcheck disable=SC2086
17269
17513
  (cd "$codebase_path" && aider --message "$phase_prompt" --yes-always --no-auto-commits --model "$aider_model" $aider_flags 2>&1) || phase_exit=$?
17270
17514
  ;;
17515
+ opencode)
17516
+ (cd "$codebase_path" && source "$_LOKI_SCRIPT_DIR/../providers/opencode.sh" && provider_invoke "$phase_prompt" 2>&1) || phase_exit=$?
17517
+ ;;
17271
17518
  *)
17272
- echo -e "${RED}Error: Unknown provider '${provider_name}'. Supported: claude, codex, cline, aider${NC}"
17519
+ echo -e "${RED}Error: Unknown provider '${provider_name}'. Supported: claude, codex, cline, aider, opencode${NC}"
17273
17520
  phase_exit=1
17274
17521
  ;;
17275
17522
  esac
@@ -18477,10 +18724,13 @@ except Exception: pass
18477
18724
  # shellcheck disable=SC2086
18478
18725
  (cd "$codebase_path" && aider --message "$heal_prompt" --yes-always --no-auto-commits --model "$aider_model" $aider_flags 2>&1) || heal_exit=$?
18479
18726
  ;;
18727
+ opencode)
18728
+ (cd "$codebase_path" && source "$_LOKI_SCRIPT_DIR/../providers/opencode.sh" && provider_invoke "$heal_prompt" 2>&1) || heal_exit=$?
18729
+ ;;
18480
18730
  # BUG-HEAL-003: Unknown provider should error, not silently succeed
18481
18731
  *)
18482
18732
  echo -e "${RED}Error: Unknown provider: $provider${NC}"
18483
- echo "Supported providers: claude, codex, cline, aider"
18733
+ echo "Supported providers: claude, codex, cline, aider, opencode"
18484
18734
  return 1
18485
18735
  ;;
18486
18736
  esac
@@ -19318,7 +19568,7 @@ with open(failover_file, 'w') as f: json.dump(d, f, indent=2)
19318
19568
  local IFS=','
19319
19569
  for p in $new_chain; do
19320
19570
  case "$p" in
19321
- claude|codex|cline|aider) ;;
19571
+ claude|codex|cline|aider|opencode) ;;
19322
19572
  *) echo -e "${RED}Error: invalid provider '$p' in chain${NC}"; return 1 ;;
19323
19573
  esac
19324
19574
  done
@@ -20517,7 +20767,7 @@ main() {
20517
20767
  # v7.5.18: early guard -- LOKI_PROVIDER=gemini is no longer supported.
20518
20768
  if [ "${LOKI_PROVIDER:-}" = "gemini" ]; then
20519
20769
  echo -e "${RED}Error: Provider 'gemini' is deprecated as of v7.5.18 and has been removed.${NC}" >&2
20520
- echo "Active providers: claude, codex, cline, aider" >&2
20770
+ echo "Active providers: claude, codex, cline, aider, opencode" >&2
20521
20771
  echo "Unset LOKI_PROVIDER or use: LOKI_PROVIDER=claude" >&2
20522
20772
  exit 1
20523
20773
  fi