agentlas 0.5.2 → 0.6.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 (41) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/route-regression.cjs +121 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. package/test/update-safety.cjs +376 -0
@@ -16,6 +16,7 @@ const caps = require("./agentlas-capabilities.cjs");
16
16
  const input = require("./agentlas-input.cjs");
17
17
  const i18n = require("./agentlas-i18n.cjs");
18
18
  const style = require("./agentlas-style.cjs");
19
+ const permissions = require("./agentlas-permissions.cjs");
19
20
 
20
21
  function runtimeLabel(rt) {
21
22
  if (!rt) return "(none)";
@@ -56,6 +57,8 @@ function makeMemoryGuard(ui, heading) {
56
57
  };
57
58
  return {
58
59
  c: ui.c,
60
+ lang: ui.lang,
61
+ t: (...a) => ui.t(...a),
59
62
  streamStart: () => ui.streamStart(),
60
63
  streamDelta: (t) => {
61
64
  if (cut) {
@@ -89,6 +92,9 @@ function makeMemoryGuard(ui, heading) {
89
92
  ok: (...a) => ui.ok(...a),
90
93
  cost: (...a) => ui.cost(...a),
91
94
  line: (...a) => ui.line(...a),
95
+ applyTaskTool: (...a) => ui.applyTaskTool(...a),
96
+ applyTaskResult: (...a) => ui.applyTaskResult(...a),
97
+ replaceTasks: (...a) => ui.replaceTasks(...a),
92
98
  };
93
99
  }
94
100
 
@@ -111,10 +117,13 @@ function makeStyleGuard(ui) {
111
117
  };
112
118
  return {
113
119
  c: ui.c,
120
+ lang: ui.lang,
121
+ t: (...a) => ui.t(...a),
114
122
  streamStart: () => {
115
123
  buf = "";
116
124
  inCode = false;
117
- ui.streamStart();
125
+ // This guard emits complete lines, so the persistent turn footer can safely stay visible.
126
+ ui.streamStart(true);
118
127
  },
119
128
  streamDelta: (text) => {
120
129
  if (!text) return;
@@ -143,6 +152,9 @@ function makeStyleGuard(ui) {
143
152
  cost: (...a) => ui.cost(...a),
144
153
  line: (...a) => ui.line(...a),
145
154
  stopSpinner: (...a) => ui.stopSpinner(...a),
155
+ applyTaskTool: (...a) => ui.applyTaskTool(...a),
156
+ applyTaskResult: (...a) => ui.applyTaskResult(...a),
157
+ replaceTasks: (...a) => ui.replaceTasks(...a),
146
158
  };
147
159
  }
148
160
 
@@ -157,7 +169,7 @@ function startRepl(opts) {
157
169
  const state = {
158
170
  subject: opts.subject || null,
159
171
  runtime: opts.runtime,
160
- permission: opts.permission || "write",
172
+ permission: opts.permission == null ? "write" : permissions.normalize(opts.permission),
161
173
  cwd: opts.cwd,
162
174
  history: [],
163
175
  native: {}, // kind → { id }
@@ -197,6 +209,7 @@ function startRepl(opts) {
197
209
  let closed = false;
198
210
  let currentAbort = null;
199
211
  let idleExitArmedUntil = 0;
212
+ const permissionCycle = permissions.createCycleController();
200
213
  rl.on("close", () => {
201
214
  if (handoff) return; // intentionally closed to hand stdin to the raw-mode composer
202
215
  closed = true;
@@ -235,6 +248,19 @@ function startRepl(opts) {
235
248
  return { projectPath: state.projectPath, agentId: state.subject && state.subject.id, permission: state.permission, cwd: state.cwd, lang: ui.lang };
236
249
  }
237
250
 
251
+ function setPermission(value, options = {}) {
252
+ const notify = options.notify !== false;
253
+ const persist = options.persist !== false;
254
+ const level = permissions.normalize(value);
255
+ state.permission = level;
256
+ if (persist) {
257
+ prefs.permission = level;
258
+ if (opts.savePrefs) opts.savePrefs(prefs);
259
+ }
260
+ if (notify) ui.ok(ui.t("permSet", level));
261
+ return level;
262
+ }
263
+
238
264
  // Session usage ledger — accumulate per runtime label (host advantage: no single-model CLI can show this).
239
265
  function recordCost(label, usage) {
240
266
  const e = state.cost[label] || (state.cost[label] = { turns: 0, in: 0, out: 0, cost: 0, ms: 0 });
@@ -270,22 +296,28 @@ function startRepl(opts) {
270
296
  // ── run one turn ──
271
297
  async function runTurn(prompt, runOptions = {}) {
272
298
  busy = true;
273
- ui.beginTurn(); // 라이브 경과시간 스피너의 턴 시작점
274
299
  currentAbort = new AbortController();
275
300
  const signal = currentAbort.signal;
276
- const recordHistoryEntry = !runOptions.side;
277
- const targetLang = H.detectResponseLanguage ? H.detectResponseLanguage(prompt, ui.lang) : ui.lang;
278
- const ctx = { ...ctxNow(), lang: targetLang, uiLang: ui.lang };
279
- const rt = state.runtime;
280
- const costLabel = runtimeLabel(rt);
281
- const runEnv = H.buildChildEnv ? await H.buildChildEnv(db, { ...ctx, cwd: state.cwd }) : process.env;
282
- Object.assign(process.env, runEnv);
283
- ui._lastUsage = null;
284
- const assistantUi = makeStyleGuard(ui);
285
- const thinkingText = i18n.t(targetLang, "thinkingWith", costLabel);
286
- ui.info(thinkingText);
287
- ui.status(thinkingText);
288
301
  try {
302
+ ui.beginTurn({
303
+ ...composerMeta(),
304
+ usage: () => usageSummaryLine(), // 턴 중에도 최신 사용량을 footer에 라이브 반영
305
+ onInterrupt: () => {
306
+ if (!currentAbort || currentAbort.signal.aborted) return;
307
+ currentAbort.abort();
308
+ ui.warn(ui.t("interrupted"));
309
+ },
310
+ }); // 작업 중에도 composer/status bar와 실제 runtime task 목록을 화면 하단에 유지
311
+ const recordHistoryEntry = !runOptions.side;
312
+ const targetLang = H.detectResponseLanguage ? H.detectResponseLanguage(prompt, ui.lang) : ui.lang;
313
+ const ctx = { ...ctxNow(), lang: targetLang, uiLang: ui.lang };
314
+ const rt = state.runtime;
315
+ const costLabel = runtimeLabel(rt);
316
+ const runEnv = H.buildChildEnv ? await H.buildChildEnv(db, { ...ctx, cwd: state.cwd }) : process.env;
317
+ ui._lastUsage = null;
318
+ const assistantUi = makeStyleGuard(ui);
319
+ const thinkingText = i18n.t(targetLang, "thinkingWith", costLabel);
320
+ ui.status(thinkingText);
289
321
  if (rt.mode === "cli") {
290
322
  const bin = H.which(H.RUNTIME_BIN[rt.kind]) || H.RUNTIME_BIN[rt.kind];
291
323
  const session = state.native[rt.kind] || (state.native[rt.kind] = {});
@@ -305,7 +337,7 @@ function startRepl(opts) {
305
337
  model: rt.model || null, // /model (claude --model, codex -m, gemini -m)
306
338
  effort: state.effort || null, // /effort (codex reasoning effort, claude think-keyword)
307
339
  mcpServers:
308
- (state.permission === "write" || state.permission === "full") && H.mcpServers
340
+ state.permission === "full" && H.mcpServers
309
341
  ? H.mcpServers(db).filter((s) => s.enabled && s.transport === "stdio")
310
342
  : [],
311
343
  env: runEnv,
@@ -341,7 +373,7 @@ function startRepl(opts) {
341
373
  apiKey,
342
374
  system: sys,
343
375
  messages,
344
- ctx,
376
+ ctx: { ...ctx, env: runEnv },
345
377
  ui: guard,
346
378
  signal,
347
379
  });
@@ -435,6 +467,23 @@ function startRepl(opts) {
435
467
  state.routePreambleOnce = null;
436
468
  applyRuntimeFor(state.subject);
437
469
  }
470
+ // 직답 모드 — 전문 에이전트 확신이 없을 때. 페르소나 없음 + 능력(이미지) 라우팅 없음:
471
+ // 런타임은 세션 기본(baseRuntime) 그대로라, 일반 질문이 gemini 등으로 끌려가지 않는다.
472
+ function setSubjectDirect() {
473
+ if (state.subject && state.subject.kind === "direct") return; // 연속 직답 — 세션/히스토리 유지
474
+ state.subject = {
475
+ kind: "direct",
476
+ id: null,
477
+ slug: "agentlas-direct",
478
+ label: ui.lang === "ko" ? "Agentlas 직답" : "Agentlas direct",
479
+ system: H.directSystemPrompt ? H.directSystemPrompt(ui.lang) : "You are the Agentlas terminal's default assistant.",
480
+ capAgent: null,
481
+ };
482
+ state.history = [];
483
+ state.routePreambleOnce = null;
484
+ state.runtime = baseRuntime;
485
+ state.native = {};
486
+ }
438
487
  function setSubjectFirm(firm) {
439
488
  const sys = H.firmSystemPrompt(db, firm);
440
489
  state.subject = {
@@ -551,8 +600,8 @@ function startRepl(opts) {
551
600
 
552
601
  function printSlashSkills() {
553
602
  ui.line("");
554
- ui.rule("Skills");
555
- for (const entry of input.slashCommandEntries()) {
603
+ ui.rule(ui.t("skills.title"));
604
+ for (const entry of input.slashCommandEntries(ui.lang)) {
556
605
  const tag = entry.category ? ui.c.faint(entry.category.padEnd(10)) : "";
557
606
  ui.line(" " + ui.c.emerald(entry.command.padEnd(18)) + tag + ui.c.dim(entry.description));
558
607
  if (!entry.aliasOf && entry.usage) ui.line(" " + ui.c.faint(" ".repeat(18) + entry.usage));
@@ -561,14 +610,10 @@ function startRepl(opts) {
561
610
 
562
611
  function printPermissions() {
563
612
  ui.line("");
564
- ui.rule("Permissions");
565
- ui.line(" " + ui.c.faint("Current") + " " + ui.c.emerald(state.permission));
566
- const rows = [
567
- ["read", "inspect files and answer; no file writes or shell automation"],
568
- ["write", "read plus create/edit files in the current work area"],
569
- ["full", "write plus shell commands and external local automation"],
570
- ];
571
- for (const [level, description] of rows) {
613
+ ui.rule(ui.t("permissions.title"));
614
+ ui.line(" " + ui.c.faint(ui.t("permissions.current")) + " " + ui.c.emerald(state.permission));
615
+ for (const level of permissions.LEVELS) {
616
+ const description = permissions.copy(level, ui.lang).description;
572
617
  const mark = level === state.permission ? "› " : " ";
573
618
  ui.line(" " + ui.c.emerald((mark + level).padEnd(10)) + ui.c.dim(description));
574
619
  }
@@ -633,7 +678,8 @@ function startRepl(opts) {
633
678
  ui.tool("$ " + cmd);
634
679
  const r = spawnSync("bash", ["-lc", cmd], { cwd: state.cwd, encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
635
680
  const out = ((r.stdout || "") + (r.stderr || "")).trim();
636
- ui.toolResult(out || ("exit " + (r.status == null ? "?" : r.status)), r.status === 0 || r.status == null);
681
+ // `!command` is explicit user output, unlike autonomous runtime traces: keep it inspectable.
682
+ ui.toolResult(out || ("exit " + (r.status == null ? "?" : r.status)), r.status === 0 || r.status == null, { verbose: true });
637
683
  }
638
684
 
639
685
  // @path — inline the contents of mentioned files into the prompt as fenced context.
@@ -693,6 +739,31 @@ function startRepl(opts) {
693
739
  case "runtime":
694
740
  setRuntime(arg);
695
741
  return true;
742
+ case "config":
743
+ case "toggles": {
744
+ // 클로드코드 /config 스타일 — 자동 엔진 개입은 전부 명시적 opt-in (기본 off).
745
+ const ENGINE_FLAGS = [
746
+ { key: "storm", pref: "autoStorm", label: ui.t("config.storm") },
747
+ { key: "network", pref: "autoNetwork", label: ui.t("config.network") },
748
+ ];
749
+ const showConfig = () => {
750
+ ui.line("");
751
+ ui.rule(ui.t("config.title"));
752
+ for (const f of ENGINE_FLAGS) {
753
+ const on = !!prefs[f.pref];
754
+ ui.line(" " + (on ? ui.c.green("● on ") : ui.c.dim("○ off")) + " " + ui.c.emerald(f.key.padEnd(9)) + ui.c.dim(f.label));
755
+ }
756
+ ui.line(" " + ui.c.faint(ui.t("config.usage")));
757
+ };
758
+ const [cfgKey, cfgVal] = arg.trim().split(/\s+/);
759
+ if (!cfgKey) return showConfig(), true;
760
+ const flag = ENGINE_FLAGS.find((f) => f.key === cfgKey.toLowerCase());
761
+ if (!flag || !/^(on|off)$/i.test(cfgVal || "")) return ui.warn(ui.t("config.usage")), true;
762
+ prefs[flag.pref] = /^on$/i.test(cfgVal);
763
+ if (opts.savePrefs) opts.savePrefs(prefs);
764
+ ui.ok(ui.t("config.set", flag.key, prefs[flag.pref] ? "on" : "off"));
765
+ return showConfig(), true;
766
+ }
696
767
  case "storm": {
697
768
  if (!arg) return ui.warn("usage: /storm <goal> [--research]"), true;
698
769
  let goal = arg;
@@ -801,16 +872,14 @@ function startRepl(opts) {
801
872
  return true;
802
873
  }
803
874
  if (!["read", "write", "full"].includes(p)) return ui.warn(ui.t("permUsage")), true;
804
- state.permission = p;
805
- ui.ok(ui.t("permSet", p));
875
+ setPermission(p);
806
876
  return true;
807
877
  }
808
878
  case "permissions":
809
879
  if (arg) {
810
880
  const p = (arg || "").toLowerCase();
811
881
  if (!["read", "write", "full"].includes(p)) return ui.warn(ui.t("permUsage")), true;
812
- state.permission = p;
813
- ui.ok(ui.t("permSet", p));
882
+ setPermission(p);
814
883
  } else {
815
884
  printPermissions();
816
885
  }
@@ -913,10 +982,7 @@ function startRepl(opts) {
913
982
  const servers = H.mcpServers ? H.mcpServers(db) : [];
914
983
  ui.line("");
915
984
  ui.rule("MCP");
916
- if (!servers.length) {
917
- ui.info(ui.t("mcp.none"));
918
- return true;
919
- }
985
+ ui.line(" " + ui.c.emerald(ui.t("mcp.playwright").padEnd(22)) + ui.c.blue("stdio ") + ui.c.green("on ") + ui.c.dim(" " + ui.t("mcp.fullOnly")));
920
986
  for (const s of servers) {
921
987
  let envKeys = [];
922
988
  try { envKeys = JSON.parse(s.env_keys_json || "[]"); } catch { /* ignore */ }
@@ -925,7 +991,7 @@ function startRepl(opts) {
925
991
  const envStr = envKeys.length ? envKeys.join(", ") : "no key";
926
992
  ui.line(" " + ui.c.emerald(String(name).padEnd(22)) + ui.c.blue(String(s.transport || "").padEnd(7)) + on + ui.c.dim(" " + envStr));
927
993
  }
928
- const wired = servers.filter((s) => s.enabled && s.transport === "stdio").length + 1; // +1 = playwright(항상)
994
+ const wired = servers.filter((s) => s.enabled && s.transport === "stdio").length + 1; // +1 = full-only Playwright
929
995
  ui.line(" " + ui.c.faint(ui.t("mcp.wired", String(wired))));
930
996
  ui.line(" " + ui.c.faint(ui.t("mcp.usage")));
931
997
  return true;
@@ -953,8 +1019,13 @@ function startRepl(opts) {
953
1019
  const s = n >= 1 && n <= list.length ? list[n - 1] : null;
954
1020
  if (!s) return ui.warn(ui.t("resume.noNum")), true;
955
1021
  const agent = H.resolveAgent(db, s.agentSlug);
956
- if (!agent) return ui.error(ui.t("noAgent", s.agentSlug)), true;
957
- setSubjectAgent(agent);
1022
+ if (agent) {
1023
+ setSubjectAgent(agent);
1024
+ } else if (s.agentSlug === "agentlas-direct") {
1025
+ setSubjectDirect(); // 직답 세션도 이어서 재개
1026
+ } else {
1027
+ return ui.error(ui.t("noAgent", s.agentSlug)), true;
1028
+ }
958
1029
  if (s.kind && H.RUNTIME_BIN[s.kind] && H.which(H.RUNTIME_BIN[s.kind])) {
959
1030
  state.runtime = { mode: "cli", kind: s.kind };
960
1031
  baseRuntime = state.runtime;
@@ -1074,10 +1145,14 @@ function startRepl(opts) {
1074
1145
  if (H.autoRouteAgent) {
1075
1146
  const choice = H.autoRouteAgent(db, t, ui.lang);
1076
1147
  if (choice) {
1077
- setSubjectAgent(choice.agent);
1148
+ if (choice.direct) {
1149
+ setSubjectDirect();
1150
+ } else {
1151
+ setSubjectAgent(choice.agent);
1152
+ }
1078
1153
  state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
1079
- ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `auto-routed to ${choice.agent.name}`);
1080
- routingNote(state.subject);
1154
+ ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `auto-routed to ${state.subject.label}`);
1155
+ if (!choice.direct) routingNote(state.subject);
1081
1156
  await runTurn(t);
1082
1157
  return ask();
1083
1158
  }
@@ -1097,53 +1172,143 @@ function startRepl(opts) {
1097
1172
  await handleSlash(t); // /exit 는 내부에서 process.exit
1098
1173
  return;
1099
1174
  }
1100
- if (!state.subject) {
1101
- const ags = H.listAgents(db);
1102
- const single = !/\s/.test(t);
1103
- if (/^\d+$/.test(t)) {
1104
- const n = parseInt(t, 10);
1105
- if (n >= 1 && n <= ags.length) {
1106
- setSubjectAgent(ags[n - 1]);
1107
- ui.ok(ui.t("switched", state.subject.label));
1108
- routingNote(state.subject);
1109
- return;
1175
+ // 직답 모드는 대상 고정이 아니라 "자동 라우팅 유지" — 매 메시지 재라우팅해,
1176
+ // 나중에 전문 요청이 오면 해당 에이전트로 자연스럽게 넘어간다.
1177
+ const directMode = !!(state.subject && state.subject.kind === "direct");
1178
+ if (!state.subject || directMode) {
1179
+ if (!state.subject) {
1180
+ const ags = H.listAgents(db);
1181
+ const single = !/\s/.test(t);
1182
+ if (/^\d+$/.test(t)) {
1183
+ const n = parseInt(t, 10);
1184
+ if (n >= 1 && n <= ags.length) {
1185
+ setSubjectAgent(ags[n - 1]);
1186
+ ui.ok(ui.t("switched", state.subject.label));
1187
+ routingNote(state.subject);
1188
+ return;
1189
+ }
1190
+ }
1191
+ if (single) {
1192
+ const a = H.resolveAgent(db, t);
1193
+ if (a) { setSubjectAgent(a); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1194
+ const f = H.resolveFirm(db, t);
1195
+ if (f) { setSubjectFirm(f); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1110
1196
  }
1111
- }
1112
- if (single) {
1113
- const a = H.resolveAgent(db, t);
1114
- if (a) { setSubjectAgent(a); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1115
- const f = H.resolveFirm(db, t);
1116
- if (f) { setSubjectFirm(f); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1117
1197
  }
1118
1198
  if (H.autoRouteAgent) {
1119
1199
  const choice = H.autoRouteAgent(db, t, ui.lang);
1120
1200
  if (choice) {
1121
- setSubjectAgent(choice.agent);
1122
- state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
1123
- ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `auto-routed to ${choice.agent.name}`);
1124
- routingNote(state.subject);
1201
+ if (choice.direct) {
1202
+ setSubjectDirect();
1203
+ if (!directMode) {
1204
+ // 첫 직답 진입에만 알림 — 연속 직답 대화에서는 조용히.
1205
+ state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
1206
+ ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `direct answer (no agent)`);
1207
+ }
1208
+ } else {
1209
+ setSubjectAgent(choice.agent);
1210
+ state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
1211
+ ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `auto-routed to ${choice.agent.name}`);
1212
+ routingNote(state.subject);
1213
+ }
1214
+ }
1215
+ }
1216
+ // 자동 엔진 개입은 명시적 opt-in 전용 (/config, 기본 off): direct 판정 + 실작업형 프롬프트일 때만.
1217
+ if (state.subject && state.subject.kind === "direct" && goalLikePrompt(t)) {
1218
+ if (prefs.autoStorm && H.stormRun) {
1219
+ ui.info(ui.t("config.autoEngage", "stormbreaker", "storm"));
1220
+ await H.stormRun(db, t, { ui, cwd: state.cwd, research: false });
1221
+ return;
1222
+ }
1223
+ if (prefs.autoNetwork && H.hepRun) {
1224
+ ui.info(ui.t("config.autoEngage", "hep-network", "network"));
1225
+ ui.line("");
1226
+ await H.hepRun(["hep-network", t, "--project", state.cwd, "--runtime", "terminal"], { cwd: state.cwd });
1227
+ return;
1125
1228
  }
1126
1229
  }
1127
1230
  }
1128
1231
  await runTurn(expandMentions(t));
1129
1232
  }
1130
1233
 
1234
+ // 실작업(goal)형 프롬프트 감지 — 질문/잡담에는 자동 엔진(storm/network)을 절대 걸지 않는다.
1235
+ function goalLikePrompt(t) {
1236
+ const s = String(t || "").trim();
1237
+ if (s.length < 12) return false;
1238
+ if (/[??]\s*$/.test(s)) return false;
1239
+ return /(해줘|해라|만들|구현|수정|배포|정리|작성|분석|리팩터|고쳐|추가|빌드|테스트|돌려|실행|자동화|automate|build|implement|fix|refactor|deploy|create|write|run|ship)/i.test(s);
1240
+ }
1241
+
1242
+ // ── 연결 LLM 세션 사용량 요약 (챗 입력창 아래 상시 표시) ──
1243
+ // 호스트 이점: 단일 모델 CLI는 못 보여주는 멀티 LLM 합산 사용량을 항상 노출한다.
1244
+ function fmtTok(n) {
1245
+ if (!n) return "0";
1246
+ if (n >= 1e6) return (n >= 1e7 ? Math.round(n / 1e6) : (n / 1e6).toFixed(1)) + "m";
1247
+ if (n >= 1e3) return (n >= 1e5 ? Math.round(n / 1e3) : (n / 1e3).toFixed(1)) + "k";
1248
+ return String(n);
1249
+ }
1250
+ function usageSummaryLine() {
1251
+ const shortLabel = (label) => (label === "claude-code" ? "claude" : label);
1252
+ const labels = [];
1253
+ const push = (label) => { if (label && label !== "(none)" && !labels.includes(label)) labels.push(label); };
1254
+ for (const kind of installedKinds()) push(kind); // 연결(설치)된 CLI 런타임 — 미사용이어도 표시
1255
+ push(runtimeLabel(state.runtime)); // 현재 런타임 (BYOK/Ollama 포함)
1256
+ for (const label of Object.keys(state.cost)) push(label); // 세션 중 사용한 나머지
1257
+ const parts = labels.map((label) => {
1258
+ const e = state.cost[label];
1259
+ if (!e) return `${shortLabel(label)} 0`;
1260
+ if (e.in || e.out) return `${shortLabel(label)} ${fmtTok(e.in)}→${fmtTok(e.out)}`;
1261
+ return `${shortLabel(label)} ${e.turns}${ui.lang === "ko" ? "턴" : "t"}`;
1262
+ });
1263
+ return `${ui.t("usageBar")} ${parts.join(" · ")}`;
1264
+ }
1265
+
1131
1266
  // ── composer (raw-mode bottom box) main loop ──
1132
- function composerStatus() {
1267
+ function composerMeta() {
1133
1268
  const rt = runtimeLabel(state.runtime);
1134
1269
  const subj = state.subject ? state.subject.label : ui.t("composer.autoroute");
1135
1270
  const eff = state.effort ? " · " + state.effort : "";
1136
- return `${rt} · ${state.permission}${eff} · ${subj} · ${ui.t("composer.hint")}`;
1271
+ const permissionLabel = permissions.copy(state.permission, ui.lang).label;
1272
+ return {
1273
+ lang: ui.lang,
1274
+ permission: state.permission,
1275
+ permissionLabel,
1276
+ status: `${rt}${eff} · ${subj} · ${ui.t("permCycleHint")} · ${ui.t("composer.hint")} · ↑↓ history`,
1277
+ usage: usageSummaryLine(), // 챗 입력창 아래 상시 LLM 사용량 표시줄
1278
+ onCyclePermission: () => {
1279
+ const cycle = permissionCycle.step(state.permission);
1280
+ if (cycle.armed) {
1281
+ return {
1282
+ ...composerMeta(),
1283
+ confirmation: ui.t("permFullArm"),
1284
+ confirmationTone: "danger",
1285
+ };
1286
+ }
1287
+ const level = setPermission(cycle.level, { notify: false, persist: false });
1288
+ return {
1289
+ ...composerMeta(),
1290
+ confirmation: cycle.enteredFull
1291
+ ? ui.t("permFullConfirm")
1292
+ : ui.t("permCycleConfirm", permissions.copy(level, ui.lang).label),
1293
+ confirmationTone: cycle.enteredFull ? "danger" : "normal",
1294
+ };
1295
+ },
1296
+ onPermissionCycleCancel: () => {
1297
+ permissionCycle.cancel();
1298
+ return { ...composerMeta(), confirmation: null, confirmationTone: null };
1299
+ },
1300
+ };
1137
1301
  }
1138
1302
  async function composerLoop() {
1139
1303
  let buffer = "";
1140
1304
  while (!closed) {
1141
1305
  let r;
1142
1306
  try {
1307
+ const meta = composerMeta();
1143
1308
  r = await composer.read({
1144
1309
  glyph: buffer ? "…" : "›",
1145
- status: composerStatus(),
1146
- suggest: (l) => input.slashCommandSuggestions(l),
1310
+ ...meta,
1311
+ suggest: (l) => input.slashCommandSuggestions(l, 12, ui.lang),
1147
1312
  complete: completer,
1148
1313
  });
1149
1314
  } catch (e) {
@@ -1176,6 +1341,8 @@ function startRepl(opts) {
1176
1341
  if (closed) return process.exit(0);
1177
1342
  if (slashPalette.setEnabled) slashPalette.setEnabled(true);
1178
1343
  const cont = buffer != null;
1344
+ // 클래식 모드에도 사용량 상시 표시 (composer 모드에선 입력박스 아래 줄이 담당)
1345
+ if (!cont && !composer && Object.keys(state.cost).length) ui.line(ui.c.faint(" " + usageSummaryLine()));
1179
1346
  rl.question(cont ? ui.c.dim(" … ") : "\n" + ui.promptLabel(), async (line) => {
1180
1347
  if (input.isContinuation(line)) {
1181
1348
  return ask((cont ? buffer + "\n" : "") + input.stripContinuation(line));
@@ -1236,10 +1403,10 @@ function startRepl(opts) {
1236
1403
  function printHelp(ui) {
1237
1404
  const c = ui.c;
1238
1405
  ui.line("");
1239
- ui.rule("Help");
1240
- ui.line(" " + c.bold(c.text("Agentlas runs local agents from this terminal, with runtime, permission, files, shell, and history controls.")));
1406
+ ui.rule(ui.t("help.title"));
1407
+ ui.line(" " + c.bold(c.text(ui.t("help.intro"))));
1241
1408
  ui.line("");
1242
- ui.line(" " + c.faint("Commands"));
1409
+ ui.line(" " + c.faint(ui.t("help.commands")));
1243
1410
  const rows = [
1244
1411
  [ui.t("help.talkKey"), ui.t("help.talk")],
1245
1412
  ["/skills", ui.t("help.skills")],
@@ -1297,10 +1464,12 @@ function printKeybindings(ui) {
1297
1464
  ["!cmd", ui.t("help.bang")],
1298
1465
  ["\\ + Enter", ui.t("help.multiline")],
1299
1466
  ["Tab", ui.t("help.tab")],
1467
+ ["Shift-Tab", ui.t("help.shiftTab")],
1468
+ ["Ctrl-T", ui.t("help.ctrlT")],
1300
1469
  ["Up / Down", ui.t("help.arrows")],
1301
1470
  ["Ctrl-C", ui.t("help.ctrlc")],
1302
1471
  ];
1303
1472
  for (const [k, v] of tips) ui.line(" " + c.emerald(k.padEnd(24)) + c.dim(v));
1304
1473
  }
1305
1474
 
1306
- module.exports = { startRepl, runtimeLabel };
1475
+ module.exports = { startRepl, runtimeLabel, makeMemoryGuard, makeStyleGuard };
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ /* Normalize only task/todo events emitted by the native runtimes.
4
+ * Ordinary tool activity is intentionally excluded: a command is not a fabricated plan item.
5
+ */
6
+
7
+ function statusOf(value, completed) {
8
+ if (completed === true) return "completed";
9
+ const status = String(value || "pending").trim().toLowerCase().replace(/[ -]+/g, "_");
10
+ if (["completed", "complete", "done", "success", "succeeded"].includes(status)) return "completed";
11
+ if (["in_progress", "active", "running", "started"].includes(status)) return "in_progress";
12
+ if (["failed", "error", "blocked", "cancelled", "canceled"].includes(status)) return "failed";
13
+ return "pending";
14
+ }
15
+
16
+ function listFrom(payload) {
17
+ if (Array.isArray(payload)) return payload;
18
+ if (!payload || typeof payload !== "object") return [];
19
+ for (const key of ["todos", "items", "tasks"]) {
20
+ if (Array.isArray(payload[key])) return payload[key];
21
+ }
22
+ return [];
23
+ }
24
+
25
+ function hasExplicitList(payload) {
26
+ if (Array.isArray(payload)) return true;
27
+ if (!payload || typeof payload !== "object") return false;
28
+ return ["todos", "items", "tasks"].some((key) => Array.isArray(payload[key]));
29
+ }
30
+
31
+ function sanitizeLabel(value, max = 500) {
32
+ let text = String(value || "");
33
+ // Runtime task text is untrusted terminal content. Strip OSC/CSI/escape controls,
34
+ // flatten line breaks, then cap it before it reaches footer row accounting.
35
+ text = text
36
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
37
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
38
+ .replace(/\x1b./g, "")
39
+ .replace(/[\r\n\t]+/g, " ")
40
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
41
+ .replace(/\s+/g, " ")
42
+ .trim();
43
+ return Array.from(text).slice(0, max).join("");
44
+ }
45
+
46
+ function normalizeTaskList(payload, source = "runtime") {
47
+ return listFrom(payload).flatMap((raw, index) => {
48
+ if (!raw || typeof raw !== "object") return [];
49
+ const label = sanitizeLabel(raw.content || raw.subject || raw.description || raw.text || raw.title || raw.activeForm);
50
+ if (!label) return [];
51
+ return [{
52
+ id: String(raw.id || raw.taskId || `${source}:${index}`),
53
+ label,
54
+ status: statusOf(raw.status, raw.completed),
55
+ source,
56
+ }];
57
+ });
58
+ }
59
+
60
+ function applyTaskTool(current, name, payload, toolId) {
61
+ const tool = String(name || "").toLowerCase();
62
+ if (tool === "todowrite" || tool === "write_todos") {
63
+ return normalizeTaskList(payload, tool);
64
+ }
65
+
66
+ if (tool !== "taskcreate" && tool !== "taskupdate") return current;
67
+
68
+ const next = Array.isArray(current) ? current.map((task) => ({ ...task })) : [];
69
+ if (tool === "taskcreate") {
70
+ const label = sanitizeLabel(payload?.subject || payload?.description || payload?.activeForm);
71
+ if (!label) return next;
72
+ next.push({ id: String(payload?.taskId || toolId || `taskcreate:${next.length}`), label, status: "pending", source: "taskcreate" });
73
+ return next;
74
+ }
75
+ if (tool === "taskupdate") {
76
+ const id = String(payload?.taskId || toolId || "");
77
+ if (!id) return next;
78
+ if (String(payload?.status || "").toLowerCase() === "deleted") return next.filter((task) => task.id !== id);
79
+ const index = next.findIndex((task) => task.id === id);
80
+ const label = sanitizeLabel(payload?.subject || payload?.description || payload?.activeForm || (index >= 0 && next[index].label) || id);
81
+ const task = { id, label, status: statusOf(payload?.status), source: "taskupdate" };
82
+ if (index >= 0) next[index] = { ...next[index], ...task };
83
+ else next.push(task);
84
+ }
85
+ return next;
86
+ }
87
+
88
+ function applyTaskResult(current, name, payload, toolId) {
89
+ const tool = String(name || "").toLowerCase();
90
+ if (!["taskcreate", "taskupdate", "tasklist"].includes(tool)) return current;
91
+ if (!payload || typeof payload !== "object") return current;
92
+ if (tool === "tasklist") {
93
+ return hasExplicitList(payload) ? normalizeTaskList(payload, "tasklist") : current;
94
+ }
95
+ const record = payload.task && typeof payload.task === "object" ? payload.task : payload;
96
+ const id = String(record.id || record.taskId || payload.taskId || "");
97
+ if (!id) return current;
98
+ const next = Array.isArray(current) ? current.map((task) => ({ ...task })) : [];
99
+ const provisional = next.findIndex((task) => task.id === String(toolId || ""));
100
+ const existing = next.findIndex((task) => task.id === id);
101
+ const index = existing >= 0 ? existing : provisional;
102
+ const prior = index >= 0 ? next[index] : null;
103
+ const label = sanitizeLabel(record.content || record.subject || record.description || record.text || record.title || prior?.label || id);
104
+ const task = { id, label, status: statusOf(record.status || prior?.status), source: tool || "task-result" };
105
+ if (index >= 0) next[index] = { ...prior, ...task };
106
+ else next.push(task);
107
+ if (existing >= 0 && provisional >= 0 && provisional !== existing) next.splice(provisional, 1);
108
+ return next;
109
+ }
110
+
111
+ module.exports = { statusOf, sanitizeLabel, normalizeTaskList, applyTaskTool, applyTaskResult };