@join-pantheon/cli 0.24.1 → 0.25.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.
- package/README.md +22 -1
- package/dist/admin-stats.js +97 -0
- package/dist/cli.js +136 -7
- package/dist/client.js +2 -0
- package/dist/decision-ui.js +40 -5
- package/dist/doctor.js +101 -60
- package/dist/feedback.js +3 -0
- package/dist/grants.js +38 -17
- package/dist/humanize.js +225 -0
- package/dist/install.js +97 -21
- package/dist/mcp-instructions.js +85 -0
- package/dist/mcp-supervisor.js +2 -1
- package/dist/mcp.js +47 -82
- package/dist/measure.js +142 -0
- package/dist/notice.js +7 -2
- package/dist/notifier.js +19 -5
- package/dist/relay.js +54 -2
- package/dist/roster-file.js +17 -0
- package/dist/run-failure.js +97 -0
- package/dist/setup-guide.js +30 -2
- package/dist/tools.js +9 -2
- package/dist/tray.js +36 -4
- package/dist/worker-policy.js +3 -1
- package/dist/worker.js +107 -13
- package/helper/macos/main.swift +52 -1
- package/helper/macos/menubar.swift +607 -17
- package/package.json +1 -1
package/dist/worker-policy.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_PATH } from "./config.js";
|
|
4
4
|
export const ASK_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
|
|
5
|
+
export const ASK_EXPIRY_MS = 14 * 24 * 60 * 60 * 1_000;
|
|
5
6
|
export function policyHome(home) {
|
|
6
7
|
return home ?? process.env.PANTHEON_HOME ?? dirname(CONFIG_PATH);
|
|
7
8
|
}
|
|
@@ -41,7 +42,8 @@ export function revoke(p, cap) {
|
|
|
41
42
|
export function mayAsk(p, cap, now = Date.now()) {
|
|
42
43
|
if (p.grants[cap])
|
|
43
44
|
return false;
|
|
44
|
-
|
|
45
|
+
const open = p.asks[cap];
|
|
46
|
+
if (open && now - open.at < ASK_EXPIRY_MS)
|
|
45
47
|
return false;
|
|
46
48
|
const deniedAt = p.denied[cap];
|
|
47
49
|
return deniedAt === undefined || now - deniedAt >= ASK_COOLDOWN_MS;
|
package/dist/worker.js
CHANGED
|
@@ -14,6 +14,7 @@ import { loadContinued, markContinued } from "./continued.js";
|
|
|
14
14
|
import { loadProfile, reservedDecisionsNote } from "./profile.js";
|
|
15
15
|
import { clip } from "./brief.js";
|
|
16
16
|
import { composeWorker } from "./notice.js";
|
|
17
|
+
import { diagnoseRun, gitProbe } from "./run-failure.js";
|
|
17
18
|
import { installedTools, preferredTool } from "./notifier.js";
|
|
18
19
|
import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
|
|
19
20
|
import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
|
|
@@ -180,6 +181,13 @@ Act, reply if a reply is owed, then finish.`;
|
|
|
180
181
|
}
|
|
181
182
|
let fleetLock = null;
|
|
182
183
|
const CONTINUATION_HORIZON_MS = 24 * 60 * 60_000;
|
|
184
|
+
export function answerWindow(askSeq, hint, fullScanDue) {
|
|
185
|
+
if (askSeq !== null)
|
|
186
|
+
return Math.max(0, askSeq - 1);
|
|
187
|
+
if (fullScanDue)
|
|
188
|
+
return 0;
|
|
189
|
+
return Math.max(0, hint);
|
|
190
|
+
}
|
|
183
191
|
export class Worker {
|
|
184
192
|
cfg;
|
|
185
193
|
opts;
|
|
@@ -492,10 +500,13 @@ export class Worker {
|
|
|
492
500
|
this.done.push(job);
|
|
493
501
|
this.client?.reportWorkerState("idle");
|
|
494
502
|
if (candidate && !job.ok) {
|
|
495
|
-
|
|
503
|
+
const failures = (this.failedRuns.get(candidate.id) ?? 0) + 1;
|
|
504
|
+
this.failedRuns.set(candidate.id, failures);
|
|
496
505
|
if (this.failedRuns.size > 200) {
|
|
497
506
|
this.failedRuns.delete(this.failedRuns.keys().next().value);
|
|
498
507
|
}
|
|
508
|
+
if (failures >= 2 && job.diagnosis)
|
|
509
|
+
await this.reportStuck(client, candidate, job.diagnosis);
|
|
499
510
|
}
|
|
500
511
|
this.running = false;
|
|
501
512
|
if (fleetLock === this)
|
|
@@ -537,11 +548,22 @@ export class Worker {
|
|
|
537
548
|
const trigger = client.recent(act.id) ?? `(${act.act} ${act.id} is not in this session's buffer)`;
|
|
538
549
|
return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false);
|
|
539
550
|
}
|
|
551
|
+
commandGrant(persona) {
|
|
552
|
+
if (persona?.allowBash === false)
|
|
553
|
+
return false;
|
|
554
|
+
const policy = loadPolicy(this.opts.home);
|
|
555
|
+
if (!useGrant(policy, "run_commands"))
|
|
556
|
+
return false;
|
|
557
|
+
savePolicy(policy, this.opts.home);
|
|
558
|
+
this.opts.log(` bash on for this run — your human granted run_commands`);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
540
561
|
async runJob(client, o, persona, policy, makeBrief, requireReply) {
|
|
541
562
|
const goal = o.goal ?? "adhoc";
|
|
542
563
|
if (this.opts.allowBash && !policy.allowBash && persona) {
|
|
543
564
|
this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
|
|
544
565
|
}
|
|
566
|
+
const allowBash = policy.allowBash || this.commandGrant(persona);
|
|
545
567
|
let branch = branchName(this.cfg.space, goal, this.client?.goals.get(goal)?.title);
|
|
546
568
|
let worktree = join(this.opts.scratch, goal);
|
|
547
569
|
let baseRef = "", baseSha = "";
|
|
@@ -566,14 +588,14 @@ export class Worker {
|
|
|
566
588
|
const plan = headlessSpawn({
|
|
567
589
|
backend: { id: "claude-code", label: "Claude Code", binaries: ["claude"] },
|
|
568
590
|
binary: this.opts.agentCmd,
|
|
569
|
-
prompt: brief, worktree, cfg: this.cfg, branch, allowBash
|
|
591
|
+
prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
|
|
570
592
|
});
|
|
571
593
|
materializeSpawnFiles(worktree, plan);
|
|
572
594
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
573
595
|
}
|
|
574
596
|
else {
|
|
575
597
|
const { backend, binary } = resolveAgent(policy.agent ?? this.opts.agent);
|
|
576
|
-
const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash
|
|
598
|
+
const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome });
|
|
577
599
|
materializeSpawnFiles(worktree, plan);
|
|
578
600
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
579
601
|
this.opts.log(` spawn ${command} ${args.slice(0, 3).join(" ")}…`);
|
|
@@ -607,18 +629,56 @@ export class Worker {
|
|
|
607
629
|
}, this.opts.home);
|
|
608
630
|
const sent = client.mineSince(startedAt, o.goal);
|
|
609
631
|
const quiet = ok && !requireReply && sent.length === 0;
|
|
632
|
+
const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, runLog }, this.opts.probe ?? gitProbe);
|
|
610
633
|
if (this.opts.notify && !quiet && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
|
|
611
634
|
try {
|
|
612
635
|
const reply = sent[sent.length - 1] ?? null;
|
|
613
|
-
this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
|
|
636
|
+
this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref, ...(diagnosis ? { diagnosis } : {}) }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
|
|
614
637
|
}
|
|
615
638
|
catch { }
|
|
616
639
|
}
|
|
617
640
|
this.opts.log(quiet ? `< read ${o.id} on ${branch} — the thread was already settled, nothing sent`
|
|
618
641
|
: ok ? `< handled ${o.id} on ${branch}`
|
|
619
|
-
:
|
|
620
|
-
|
|
621
|
-
|
|
642
|
+
: `! ${o.id} stopped [${diagnosis.cause}]: ${diagnosis.says} (transcript: ${runLog})`);
|
|
643
|
+
return { obligation: o, branch, worktree, ok, detail, ...(diagnosis ? { diagnosis } : {}) };
|
|
644
|
+
}
|
|
645
|
+
async reportStuck(client, o, d) {
|
|
646
|
+
const key = `stuck:${o.id}`;
|
|
647
|
+
const space = this.spaceKey();
|
|
648
|
+
if (loadContinued(space, this.opts.home).has(key))
|
|
649
|
+
return;
|
|
650
|
+
const policy = loadPolicy(this.opts.home);
|
|
651
|
+
const asking = !!d.capability && mayAsk(policy, d.capability);
|
|
652
|
+
const what = clip(o.body, 120);
|
|
653
|
+
const lines = [
|
|
654
|
+
`Two runs on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
|
|
655
|
+
``,
|
|
656
|
+
`WHY IT STOPPED. ${d.says}`,
|
|
657
|
+
`WHAT UNBLOCKS IT. ${d.fix}`,
|
|
658
|
+
...(d.dir && d.lines?.length ? [``, `On this machine, in ${d.dir}:`, ...d.lines.map((l) => ` ${l}`)] : []),
|
|
659
|
+
``,
|
|
660
|
+
`Nothing was sent to the teammate who asked, and the request is still open on them.`,
|
|
661
|
+
`It will not be retried unattended — a live session here, or you, has to move it.`,
|
|
662
|
+
];
|
|
663
|
+
if (asking) {
|
|
664
|
+
lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. Allowing it lets a woken`, `run execute tests and git in its own isolated worktree — never in your checkout.`);
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
const sent = await client.send({
|
|
668
|
+
act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: client.readSeq,
|
|
669
|
+
summary: clip(`Stuck on you: ${d.says}`, 190),
|
|
670
|
+
body: lines.join("\n"),
|
|
671
|
+
});
|
|
672
|
+
markContinued(space, key, this.opts.home);
|
|
673
|
+
if (asking && d.capability) {
|
|
674
|
+
recordAsk(policy, d.capability, sent.id);
|
|
675
|
+
savePolicy(policy, this.opts.home);
|
|
676
|
+
}
|
|
677
|
+
this.opts.log(`* escalated ${o.id}: ${d.cause}${asking ? ` (and asked for ${d.capability})` : ""}`);
|
|
678
|
+
}
|
|
679
|
+
catch (e) {
|
|
680
|
+
this.opts.log(`! could not report the stuck obligation on the ledger: ${e.message}`);
|
|
681
|
+
}
|
|
622
682
|
}
|
|
623
683
|
enqueueContinuations(client, arrived) {
|
|
624
684
|
this.loadContinued();
|
|
@@ -678,13 +738,18 @@ export class Worker {
|
|
|
678
738
|
const home = this.opts.home;
|
|
679
739
|
{
|
|
680
740
|
const policy = loadPolicy(home);
|
|
681
|
-
const open = policy.asks.self_upgrade;
|
|
682
741
|
let changed = false;
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
742
|
+
for (const cap of Object.keys(policy.asks)) {
|
|
743
|
+
const open = policy.asks[cap];
|
|
744
|
+
if (!open)
|
|
745
|
+
continue;
|
|
746
|
+
const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref === open.act);
|
|
747
|
+
const body = fromStream?.body ?? await this.fetchGrantAnswer(open.act, client.head - 200);
|
|
748
|
+
if (body === null)
|
|
749
|
+
continue;
|
|
686
750
|
const applied = applyAnswer(policy, open.act, body);
|
|
687
751
|
if (applied) {
|
|
752
|
+
this.askAt.delete(open.act);
|
|
688
753
|
changed = true;
|
|
689
754
|
this.opts.log(`* grant request answered: ${applied.cap} -> ${applied.verdict}`);
|
|
690
755
|
}
|
|
@@ -756,10 +821,39 @@ export class Worker {
|
|
|
756
821
|
}
|
|
757
822
|
}
|
|
758
823
|
}
|
|
759
|
-
|
|
824
|
+
askAt = new Map();
|
|
825
|
+
lastFullScan = 0;
|
|
826
|
+
async actSeq(id) {
|
|
827
|
+
try {
|
|
828
|
+
const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
829
|
+
const q = `space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}`;
|
|
830
|
+
const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(3000) });
|
|
831
|
+
if (!res.ok)
|
|
832
|
+
return null;
|
|
833
|
+
const j = await res.json();
|
|
834
|
+
return typeof j.act?.seq === "number" ? j.act.seq : null;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async fetchGrantAnswer(askActId, since = 0) {
|
|
841
|
+
let seq = this.askAt.get(askActId) ?? null;
|
|
842
|
+
if (seq === null) {
|
|
843
|
+
seq = await this.actSeq(askActId);
|
|
844
|
+
if (seq !== null)
|
|
845
|
+
this.askAt.set(askActId, seq);
|
|
846
|
+
}
|
|
847
|
+
let due = false;
|
|
848
|
+
if (seq === null) {
|
|
849
|
+
due = Date.now() - this.lastFullScan > 5 * 60_000;
|
|
850
|
+
if (due)
|
|
851
|
+
this.lastFullScan = Date.now();
|
|
852
|
+
}
|
|
853
|
+
const from = answerWindow(seq, since, due);
|
|
760
854
|
try {
|
|
761
855
|
const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
762
|
-
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since
|
|
856
|
+
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=${from}`;
|
|
763
857
|
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
764
858
|
const j = await res.json();
|
|
765
859
|
const ans = (j.acts ?? []).find((a) => a.act === "ANSWER" && a.ref === askActId);
|
package/helper/macos/main.swift
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* (the choice becomes the default), through a .command file
|
|
10
10
|
* Terminal runs — no automation permission needed
|
|
11
11
|
* Show details prints the act and the decision in Terminal
|
|
12
|
+
* Fix it here (a failed run) opens a Terminal where the work is, steps shown
|
|
13
|
+
* Done (decisions) answers it as handled, silently — the reminders stop
|
|
12
14
|
* Dismiss / × closes
|
|
13
15
|
*
|
|
14
16
|
* It owns no state. Everything it needs arrives as one JSON document:
|
|
@@ -25,13 +27,15 @@ import AppKit
|
|
|
25
27
|
let args = CommandLine.arguments
|
|
26
28
|
|
|
27
29
|
if args.count == 2, args[1] == "version" {
|
|
28
|
-
print("
|
|
30
|
+
print("4")
|
|
29
31
|
exit(0)
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
struct Tool: Decodable { let id: String; let label: String; let kind: String?; let bundle: String?; let deeplink: String? }
|
|
33
35
|
struct Primary: Decodable { let label: String; let `default`: String; let tools: [Tool]; let prompt: String }
|
|
34
36
|
struct Details: Decodable { let act: String?; let decision: String? }
|
|
37
|
+
/// The one thing a person could do about a failed run: where, and which steps.
|
|
38
|
+
struct Fix: Decodable { let label: String; let dir: String; let lines: [String]; let why: String }
|
|
35
39
|
struct Notice: Decodable {
|
|
36
40
|
let kind: String
|
|
37
41
|
let who: String
|
|
@@ -43,8 +47,11 @@ struct Notice: Decodable {
|
|
|
43
47
|
let sound: Bool
|
|
44
48
|
let primary: Primary?
|
|
45
49
|
let details: Details?
|
|
50
|
+
let fix: Fix?
|
|
46
51
|
/** where to write the chosen action and which .command files to open — supplied by the CLI */
|
|
47
52
|
let bin_dir: String
|
|
53
|
+
/** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
|
|
54
|
+
let pantheon: String?
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
var notice: Notice
|
|
@@ -138,6 +145,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
138
145
|
act(["action": "details", "act": notice.details?.act as Any, "decision": notice.details?.decision as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-details.command")
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
/// Done: write the action, run the CLI without a Terminal, close. The CLI answers the decision as handled.
|
|
149
|
+
@objc func markDone(_ sender: Any?) {
|
|
150
|
+
guard !notice.bin_dir.isEmpty, let decision = notice.details?.decision else { NSApp.terminate(nil); return }
|
|
151
|
+
let dir = URL(fileURLWithPath: notice.bin_dir)
|
|
152
|
+
let payload: [String: Any] = ["action": "done", "decision": decision, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }
|
|
153
|
+
if let data = try? JSONSerialization.data(withJSONObject: payload) {
|
|
154
|
+
try? data.write(to: dir.appendingPathComponent(".hud-action.json"), options: .atomic)
|
|
155
|
+
}
|
|
156
|
+
let bin = (notice.pantheon?.isEmpty == false) ? notice.pantheon! : "pantheon"
|
|
157
|
+
let quoted = "'" + bin.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
158
|
+
let p = Process()
|
|
159
|
+
p.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
160
|
+
p.arguments = ["-lc", "\(quoted) explain --queued >/dev/null 2>&1"] // login shell: PATH with node, as the user's own terminal has
|
|
161
|
+
try? p.run()
|
|
162
|
+
NSApp.terminate(nil)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Fix it here: open a Terminal where the work is, with the steps shown. The
|
|
166
|
+
/// CLI asks before running anything — a run that just failed is the wrong
|
|
167
|
+
/// moment to take another unattended action on someone's machine.
|
|
168
|
+
@objc func fixIt(_ sender: Any?) {
|
|
169
|
+
guard let f = notice.fix else { NSApp.terminate(nil); return }
|
|
170
|
+
act(["action": "fix", "dir": f.dir, "lines": f.lines, "why": f.why], command: "pantheon-details.command")
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
@objc func legacyOpen(_ sender: Any?) {
|
|
142
174
|
if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
|
|
143
175
|
if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
|
|
@@ -260,6 +292,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
260
292
|
constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
261
293
|
trailing = d
|
|
262
294
|
}
|
|
295
|
+
if notice.fix != nil {
|
|
296
|
+
let fix = NSButton(title: notice.fix!.label, target: self, action: #selector(fixIt(_:)))
|
|
297
|
+
fix.isBordered = false; fix.font = .systemFont(ofSize: 12, weight: .medium); fix.contentTintColor = .controlAccentColor
|
|
298
|
+
fix.toolTip = notice.fix!.why
|
|
299
|
+
fix.translatesAutoresizingMaskIntoConstraints = false
|
|
300
|
+
root.addSubview(fix)
|
|
301
|
+
constraints += [fix.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), fix.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
302
|
+
trailing = fix
|
|
303
|
+
}
|
|
304
|
+
if notice.details?.decision != nil {
|
|
305
|
+
// A decision can be settled from the banner itself: one click, no terminal, no more reminders.
|
|
306
|
+
let done = NSButton(title: "Done", target: self, action: #selector(markDone(_:)))
|
|
307
|
+
done.isBordered = false; done.font = .systemFont(ofSize: 12, weight: .medium); done.contentTintColor = .controlAccentColor
|
|
308
|
+
done.setAccessibilityLabel("Mark this decision handled")
|
|
309
|
+
done.translatesAutoresizingMaskIntoConstraints = false
|
|
310
|
+
root.addSubview(done)
|
|
311
|
+
constraints += [done.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), done.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
312
|
+
trailing = done
|
|
313
|
+
}
|
|
263
314
|
let dis = NSButton(title: "Dismiss", target: self, action: #selector(dismiss(_:)))
|
|
264
315
|
dis.isBordered = false; dis.font = .systemFont(ofSize: 12); dis.contentTintColor = .secondaryLabelColor
|
|
265
316
|
dis.translatesAutoresizingMaskIntoConstraints = false
|